Skip to content

feat: Allow actions to return result values, exposed to the subsequent sequential action as $(this:result)#4065

Open
jswalden wants to merge 1 commit intobitfocus:mainfrom
jswalden:action-callback-return-result
Open

feat: Allow actions to return result values, exposed to the subsequent sequential action as $(this:result)#4065
jswalden wants to merge 1 commit intobitfocus:mainfrom
jswalden:action-callback-return-result

Conversation

@jswalden
Copy link
Copy Markdown
Contributor

@jswalden jswalden commented Apr 2, 2026

The one moderately-major thing in all this that I am uncertain about, is how to propagate a result to a subsequent concurrent action group that contains waits.

If I read the code correctly, Companion basically splits concurrent action groups into sets of non-wait actions, and then it performs all those non-waits of the first set at once, then does the intervening wait, then does the next set of non-waits, etc.

I think it is reasonable to propagate a previous result to all actions in a concurrent action group that doesn't contain any waits. It is less clear that it makes sense to do so when the action group contains waits. Or that they should propagate beyond the first set of actions? For prototype-ful purposes I just acted like the waits weren't there. For actual suitability for landing purposes, maybe that should change. Don't propagate if there are any waits? Only propagate to actions before the first wait? The answer isn't immediately obvious to me.

Summary by CodeRabbit

  • New Features

    • Actions now produce and pass results between steps so later actions can consume the previous action's output via $(this:result).
    • UI: added "The result of the action sequentially preceding this one" local-variable option.
  • Documentation

    • Added docs for the new $(this:result) builtin local variable and how to use it in action sequences.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6faeb0b9-511c-43f4-9886-d2aceb31f999

📥 Commits

Reviewing files that changed from the base of the PR and between 21b0e38 and e646622.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (26)
  • companion/lib/Controls/ActionRunner.ts
  • companion/lib/Controls/ControlStore.ts
  • companion/lib/Controls/ControlTypes/Button/Base.ts
  • companion/lib/Controls/ControlTypes/Button/Preset.ts
  • companion/lib/Controls/ControlTypes/Button/Util.ts
  • companion/lib/Controls/ControlTypes/Triggers/Trigger.ts
  • companion/lib/Controls/Controller.ts
  • companion/lib/Controls/Entities/EntityIsInvertedManager.ts
  • companion/lib/Controls/Entities/EntityListPoolBase.ts
  • companion/lib/Controls/IControlStore.ts
  • companion/lib/ImportExport/Backups.ts
  • companion/lib/ImportExport/Export.ts
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Instance/Connection/ChildHandlerLegacy.ts
  • companion/lib/Instance/Connection/ChildHandlerNew.ts
  • companion/lib/Instance/Connection/EntityManager.ts
  • companion/lib/Instance/Connection/IpcTypesNew.ts
  • companion/lib/Instance/Connection/Thread/Entrypoint.ts
  • companion/lib/Internal/Controller.ts
  • companion/lib/Preview/ExpressionStream.ts
  • companion/lib/Preview/Graphics.ts
  • companion/lib/Surface/Controller.ts
  • companion/lib/Variables/Values.ts
  • companion/package.json
  • docs/user-guide/3_config/variables.md
  • webui/src/Controls/LocalVariablesStore.tsx
✅ Files skipped from review due to trivial changes (6)
  • companion/lib/ImportExport/Backups.ts
  • companion/lib/Preview/ExpressionStream.ts
  • companion/lib/ImportExport/Export.ts
  • companion/lib/Internal/Controller.ts
  • webui/src/Controls/LocalVariablesStore.tsx
  • companion/package.json
🚧 Files skipped from review as they are similar to previous changes (14)
  • companion/lib/Controls/ControlTypes/Triggers/Trigger.ts
  • companion/lib/Controls/ControlTypes/Button/Preset.ts
  • companion/lib/Preview/Graphics.ts
  • companion/lib/Instance/Connection/EntityManager.ts
  • companion/lib/Instance/Connection/Thread/Entrypoint.ts
  • companion/lib/Controls/IControlStore.ts
  • companion/lib/Controls/ControlTypes/Button/Base.ts
  • companion/lib/Controls/Entities/EntityIsInvertedManager.ts
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Controls/ControlStore.ts
  • companion/lib/Instance/Connection/ChildHandlerLegacy.ts
  • companion/lib/Instance/Connection/ChildHandlerNew.ts
  • companion/lib/Controls/ActionRunner.ts
  • companion/lib/Controls/Entities/EntityListPoolBase.ts

📝 Walkthrough

Walkthrough

This pull request threads action results through the execution pipeline: actions now return a VariableValue, parser creation accepts a new previousResult parameter, $(this:result) is injected as a local variable, and IPC/action response types are extended to carry action results.

Changes

Cohort / File(s) Summary
Action Execution Core
companion/lib/Controls/ActionRunner.ts, companion/lib/Controls/ControlStore.ts, companion/lib/Controls/Controller.ts, companion/lib/Controls/IControlStore.ts
Action runner methods now return Promise<VariableValue>; createVariablesAndExpressionParser signatures gain a previousResult parameter and it is forwarded through controllers.
Action Handler & IPC Layer
companion/lib/Instance/Connection/ChildHandlerApi.ts, companion/lib/Instance/Connection/ChildHandlerLegacy.ts, companion/lib/Instance/Connection/ChildHandlerNew.ts, companion/lib/Instance/Connection/IpcTypesNew.ts, companion/lib/Instance/Connection/Thread/Entrypoint.ts
actionRun now returns Promise<VariableValue>; RunActionExtras includes previousResult. IPC response reshaped into a discriminated union carrying result on success.
Control Type Updates
companion/lib/Controls/ControlTypes/Button/Base.ts, companion/lib/Controls/ControlTypes/Button/Preset.ts, companion/lib/Controls/ControlTypes/Button/Util.ts, companion/lib/Controls/ControlTypes/Triggers/Trigger.ts
Calls to build expression parsers updated to pass the new previousResult (often undefined) and action run contexts include previousResult.
Entity & Variable System
companion/lib/Controls/Entities/EntityIsInvertedManager.ts, companion/lib/Controls/Entities/EntityListPoolBase.ts, companion/lib/Instance/Connection/EntityManager.ts, companion/lib/Variables/Values.ts
Entity/variable APIs updated to accept previousResult. VariablesValues now injects $(this:result) from previousResult.
Miscellaneous Controllers & Preview
companion/lib/Internal/Controller.ts, companion/lib/ImportExport/Backups.ts, companion/lib/ImportExport/Export.ts, companion/lib/Preview/ExpressionStream.ts, companion/lib/Preview/Graphics.ts, companion/lib/Surface/Controller.ts
Parser creation calls updated to include the new previousResult argument (commonly undefined or context-mapped).
UI & Documentation
webui/src/Controls/LocalVariablesStore.tsx, docs/user-guide/3_config/variables.md
Added this:result local variable option and documented it as "the result of the action sequentially preceding this one."
Package manifest formatting
companion/package.json
File reformatting changed indentation to tabs; no package dependency/version changes.

Poem

A chain of actions, one by one,
Each hands the next a setting sun,
$(this:result) whispers what came before,
Parsers accept it, passing lore,
Small change—big ripple, onward run ✨

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main feature: enabling actions to return result values exposed to subsequent sequential actions via the new $(this:result) variable.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
companion/lib/Internal/Controller.ts (1)

448-470: ⚠️ Potential issue | 🟠 Major

Internal action results are still dropped on this path.

This now wires $(this:result) into parsing, but Line 448 still returns Promise<void>, and the fragment dispatch below still treats the fragment return as a truthy “handled” flag. The result is that internal actions cannot propagate any value to the next action, and falsy results like 0, false, or '' additionally fall through as unhandled. Please switch this path to an explicit handled/result contract and return the actual result from executeAction().

Also applies to: 496-505

companion/lib/Controls/ActionRunner.ts (1)

89-110: ⚠️ Potential issue | 🟠 Major

Please lock down previousResult semantics across concurrent groups with waits

Friendly heads-up: actions after a wait currently still receive the same extras.previousResult as actions before the wait (Line 104), which matches the prototype but leaves ambiguous behavior in a user-visible path. This can make $(this:result) feel non-deterministic once waits are involved.

I’d recommend choosing and enforcing one explicit policy here (e.g., clear after first wait, or disable propagation when any wait exists).

Suggested direction (disable propagation when waits are present)
 		} else {
 			const groupedActions = this.#splitActionsAroundWaits(actions)
+			const hasWaits = groupedActions.some((group) => !!group.waitAction)

 			const ps: Promise<VariableValue>[] = []

 			for (const { waitAction, actions } of groupedActions) {
 				if (extras.abortDelayed.aborted) break
@@
 				// Spawn all the actions in parallel
 				for (const action of actions) {
+					const actionExtras: RunActionExtras = {
+						...extras,
+						previousResult: hasWaits ? undefined : extras.previousResult,
+					}
 					ps.push(
-						this.#runAction(action, extras).catch((e) => {
+						this.#runAction(action, actionExtras).catch((e) => {
 							this.#logger.silly(`Error executing action for ${action.connectionId}: ${e.message ?? e}`)
 							return undefined
 						})
 					)
 				}
🧹 Nitpick comments (2)
companion/lib/Controls/IControlStore.ts (1)

48-52: Consider making previousResult optional in the interface

Nice addition overall. For this interface, making previousResult optional can keep behavior the same while avoiding repetitive undefined at every non-action call site.

Proposed tweak
 createVariablesAndExpressionParser(
 	controlId: string | null | undefined,
 	overrideVariableValues: VariableValues | null,
-	previousResult: VariableValue
+	previousResult?: VariableValue
 ): VariablesAndExpressionParser
companion/lib/Controls/ActionRunner.ts (1)

75-83: Nice chaining logic — consider avoiding in-place mutation of extras

This works, but mutating extras.previousResult directly (Line 77) makes the function stateful against its input object. Using a local previousResult (and passing a fresh extras object per action) keeps behavior easier to reason about.

Refactor sketch
 		if (executeSequential) {
 			// Future: abort on error?
+			let previousResult = extras.previousResult

 			for (const action of actions) {
 				if (extras.abortDelayed.aborted) break
-				extras.previousResult = await this.#runAction(action, extras).catch((e) => {
+				previousResult = await this.#runAction(action, { ...extras, previousResult }).catch((e) => {
 					this.#logger.silly(`Error executing action for ${action.connectionId}: ${e.message ?? e}`)
 					return undefined
 				})
 			}

-			return extras.previousResult
+			return previousResult

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d3bd3af-aba0-4f33-b03d-b6cd4ddcdc72

📥 Commits

Reviewing files that changed from the base of the PR and between 0a7ada7 and 21b0e38.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (26)
  • companion/lib/Controls/ActionRunner.ts
  • companion/lib/Controls/ControlStore.ts
  • companion/lib/Controls/ControlTypes/Button/Base.ts
  • companion/lib/Controls/ControlTypes/Button/Preset.ts
  • companion/lib/Controls/ControlTypes/Button/Util.ts
  • companion/lib/Controls/ControlTypes/Triggers/Trigger.ts
  • companion/lib/Controls/Controller.ts
  • companion/lib/Controls/Entities/EntityIsInvertedManager.ts
  • companion/lib/Controls/Entities/EntityListPoolBase.ts
  • companion/lib/Controls/IControlStore.ts
  • companion/lib/ImportExport/Backups.ts
  • companion/lib/ImportExport/Export.ts
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Instance/Connection/ChildHandlerLegacy.ts
  • companion/lib/Instance/Connection/ChildHandlerNew.ts
  • companion/lib/Instance/Connection/EntityManager.ts
  • companion/lib/Instance/Connection/IpcTypesNew.ts
  • companion/lib/Instance/Connection/Thread/Entrypoint.ts
  • companion/lib/Internal/Controller.ts
  • companion/lib/Preview/ExpressionStream.ts
  • companion/lib/Preview/Graphics.ts
  • companion/lib/Surface/Controller.ts
  • companion/lib/Variables/Values.ts
  • companion/package.json
  • docs/user-guide/3_config/variables.md
  • webui/src/Controls/LocalVariablesStore.tsx

Comment on lines +124 to +125
- The result of the action sequentially preceding this one
- Variable: `this:result`
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Please add a short caveat for concurrent/wait action groups

Love that this:result is documented. Given current implementation notes, a brief line clarifying that behavior is guaranteed for sequential chains (and may differ for concurrent groups with waits) would help avoid user confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brevity/clarity versus precision tradeoff. Can tweak if someone wants it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jswalden — totally fair point! Here's a minimal phrasing that tries to stay concise while flagging the edge case:

- The result of the action sequentially preceding this one (sequential action chains only)
  - Variable: `this:result`

Or alternatively, a single parenthetical note tucked on the same line keeps the list clean without adding extra bullet depth. Either way, happy to leave it as-is if you'd prefer to revisit once the concurrent-group propagation semantics are finalized — the current wording isn't wrong, just silent on the edge case.

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

Internal action results are still dropped on this path.

I chose for internal actions to not produce their own results, and also not to propagate a prior result across them. (Why should they? Don't see good reason for them to, honestly.) It makes most sense IMO for only an action that produces a result itself, to be source of one.

Please lock down previousResult semantics across concurrent groups with waits

Flagged in the initial comment.

Consider making previousResult optional in the interface

Explicit is better than implicit. There's always a previous result value/variable. It's not actually optional semantically. (I even considered using const previousResult: VariableValue = undefined every place, for documentation, but didn't carry through on it.)

Nice chaining logic — consider avoiding in-place mutation of extras

I kind of prefer less object creation churn, but Julian's the one who will have a meaningful preference here, probably.

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

This will fix #4064

@jswalden jswalden changed the title feat: Allow actions to return result values, exposed to the subsequent sequential action as $(this:result). (#4064) feat: Allow actions to return result values, exposed to the subsequent sequential action as $(this:result) Apr 2, 2026
…entially succeeding actions as `$(this:result)`.
@makstech
Copy link
Copy Markdown
Contributor

makstech commented Apr 2, 2026

The idea is awesome and I 100% support it but my suggestion to your question

how to propagate a result to a subsequent concurrent action group that contains waits.

Why not use something like n8n does it: any node that's downstream, can access any upstream node's result using the node's name. For example, first node is called "Message", the next node can get the data directly with $json (in n8n) or $(this:result) with your implementation in Companion, but then next nodes or even the current one, can also get it with $('Message').item.json. This is an example that works elsewhere and is just an idea, but obviously completely out of scope for this PR.

@Julusian
Copy link
Copy Markdown
Member

Julusian commented Apr 2, 2026

I'm wondering if this should work such that for any action definition which says 'i can return a value' we let the user pick a local-variable name to write the value into.
move the state into the existing local variable system, instead of introducing a new flow for state that the user will need to understand behaves differently.

This would make it similar to how the generic-http module (and others?) do this today, but as they predate local-variables they can only write to custom variables. Perhaps it should be able to do both, but lets limit this to local to begin with.

I see pros and cons to each:

  • This will be more tolerant to multiple concurrent executions, using local variables will not
  • Local variables makes it obvious how things will flow around waits
  • Sometimes, the user will want to fire off multiple requests in parallel and operate on the result of all of them, local variables solves the id/reference problem
  • This is simpler to use in simple scenarios, no boilerplate of creating a local variable

$('Message').item.json.

I'm not opposed to something like this in the future, but I think what we use as the identifier needs some thought first. Something like this would be a start to addressing some other requests to reference/update properties of actions/feedbacks (some of which I am not sure is a good idea)

@jswalden jswalden force-pushed the action-callback-return-result branch from 21b0e38 to e646622 Compare April 2, 2026 21:28
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

Why not use something like n8n does it: any node that's downstream, can access any upstream node's result using the node's name. For example, first node is called "Message", the next node can get the data directly with $json (in n8n) or $(this:result) with your implementation in Companion, but then next nodes or even the current one, can also get it with $('Message').item.json.

My concern here is that an action that sets a result, should not have that result remain live for unbounded period of time. (At least not unless a subsequent user of it chooses to keep it live.) Tying results directly and only to a bounded set of sequentially-next actions does this. Whereas if you could refer backwards to previous actions, perhaps even dynamically, then every action's result has to be kept alive "just in case".

There are plainly a bunch of ways to skin this cat. "Use it immediately or lose it" doesn't seem like too complicated a way to grasp, to me.

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

I'm wondering if this should work such that for any action definition which says 'i can return a value' we let the user pick a local-variable name to write the value into. move the state into the existing local variable system, instead of introducing a new flow for state that the user will need to understand behaves differently.

That makes some idiomatic sense.

I see pros and cons to each:

* This will be more tolerant to multiple concurrent executions, using local variables will not

Yeah. (If I were to grump on terminology, I'd name local variables as "per-entity variables", or better non-internal idiom terminology. And then maybe have some kind of truly local variable that can be created by an internal action, set from expression [or with this enhancement from an action result], that remains live only for the duration of the sequential action group it's found in. And would not have concurrency considerations to think about. But I digress.)

I guess the concurrency of local variables is my biggest quirk/beef here. Same as, but at least significantly reduced in troublesomeness, of a module variable, or of writing to a custom variable.

* Local variables makes it obvious how things will flow around waits

I think you could arguably say that implicitly piping the result distributing it across a concurrent action group is...of uncertain intuitiveness, as mechanism to enable multiple distinct manners of consuming an action result. So possibly the root problem isn't really the waits.

(I don't fully understand why waits in concurrent action groups are even given special meaning. Why not just require users to create a sequential action group, then interleave waits between concurrent action groups? But I digress even harder...)

* Sometimes, the user will want to fire off multiple requests in parallel and operate on the result of all of them, local variables solves the id/reference problem

* This is simpler to use in simple scenarios, no boilerplate of creating a local variable

Forcing stuff to be named doesn't feel like boilerplate to me. What does feel like boilerplate, is having to create/name them out of the actual UI of the step flow being executed. And having that variable exist across all steps, rather than only narrowly in the exact set of actions the computation/initialization happens in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants